如何配置 Webpack SourceMap 的最佳实践|原理篇
30 秒速记
- 核心判断:源码转换先解析为 AST,再按规则变换结构,最后生成目标代码和位置映射
- 原理主线:围绕 「Source Map 简介」、「Webpack 中配置 Source M」、「写在最后」 建立输入、状态变化与输出之间的因果关系
- 文章范围:介绍了什么是 Source Map、其工作原理及在 Webpack 中的配置方法,帮助前端开发者理解如何通过 Source Map 实现高效调试和错误定位,并对不同 Source Map 模式的优缺点进行了对比分析,助力提升前端开发与生产环
- 边界与代价:语法转换不等于补齐运行时能力;错误映射还依赖每一层工具正确传递
Source Map - 工程落地:配置时要明确目标环境、插件顺序、polyfill 策略和映射暴露范围
Webpack Source Map 没有统一的最优配置,应按调试精度、构建速度和源码暴露风险来选。 开发环境重视重建速度,可选基于 eval 的模式;需要准确定位源文件及行列时,可用 eval-source-map 或 source-map。生产环境若要排查线上错误,可以生成 hidden-source-map,避免在产物中主动引用映射文件;若不希望包含源码内容,可考虑 nosources-source-map。具体模式仍应以项目锁定版本的 devtool 文档和实际构建结果为准。
这篇文章不要按 API 清单来背。先用上面的 Mind Map 建立全局结构,再通过交互 DEMO 观察正常路径和边界路径如何改变状态;阅读正文时重点核对每一步的输入、负责执行的参与者、产生的中间状态以及最终可观察结果。遇到版本敏感结论,要把“历史实现”“当前行为”和“工程兼容策略”分开说明;遇到性能或架构取舍,则用实际指标、失败现象和验证手段支撑判断。
版本校准: 旧文中的 webpack 4、JSONP 更新清单或 react-hot-loader 代码用于解释历史链路,不应直接复制到新项目。当前 webpack-dev-server 4+ 默认启用 HMR,严格 ESM 可使用 import.meta.webpackHot;生产环境不得携带 HMR runtime。以 webpack HMR 官方指南 和项目锁定版本为准。
通过构建或者编译之类的操作,我们将开发阶段编写的源代码转换为能够在生产环境中运行的代码,这种进步同时也意味着我们实际运行的代码和我们真正编写的代码之间存在很大的差异。
在这种情况下,如果需要调试我们的应用,或是应用运行的过程中出现意料之外的错误,那我们将无从下手。因为无论是调试还是报错,都是基于构建后的代码进行的,我们只能看到错误信息在构建后代码中具体的位置,却很难直接定位到源代码中对应的位置。
所以我们今天来聊聊如何借助工具解决现代化前端应用的调试问题。
# Source Map 简介
Source Map(源代码地图)就是解决此类问题最好的办法,从它的名字就能够看出它的作用:映射转换后的代码与源代码之间的关系。一段转换后的代码,通过转换过程中生成的 Source Map 文件就可以逆向解析得到对应的源代码。

目前很多第三方库在发布的文件中都会同时提供一个 .map 后缀的 Source Map 文件。例如 jQuery。我们可以打开它的 Source Map 文件看一下,如下图所示:

这是一个 JSON 格式的文件,为了更容易阅读,我提前对该文件进行了格式化。这个 JSON 里面记录的就是转换后和转换前代码之间的映射关系,主要存在以下几个属性:
version是指定所使用的 Source Map 标准版本;sources中记录的是转换前的源文件名称,因为有可能出现多个文件打包转换为一个文件的情况,所以这里是一个数组;names是源代码中使用的一些成员名称,我们都知道一般压缩代码时会将我们开发阶段编写的有意义的变量名替换为一些简短的字符,这个属性中记录的就是原始的名称;mappings属性,这个属性最为关键,它是一个叫作 base64-VLQ 编码的字符串,里面记录的信息就是转换后代码中的字符与转换前代码中的字符之间的映射关系,具体如下图所示:

一般我们会在转换后的代码中通过添加一行注释的方式来去引入 Source Map 文件。不过这个特性只是用于开发调试的,所以最新版本的 jQuery 已经去除了引入 Source Map 的注释,我们需要手动添加回来,这里我们在最后一行添加 //# sourceMappingURL=jquery-3.4.1.min.map,具体效果如下:

这样我们在 Chrome 浏览器中如果打开了开发人员工具,它就会自动请求这个文件,然后根据这个文件的内容逆向解析出来源代码,以便于调试。同时因为有了映射关系,所以代码中如果出现了错误,也就能自动定位找到源代码中的位置了。
我们回到浏览器中,打开开发人员工具,找到 Source 面板,这里我们就能看到转换前的 jQuery 源代码了,具体效果如下图所示:

我们还可以添加一个断点,然后刷新页面,进行单步调试,此时调试过程中使用的就是源代码而不是压缩过后的代码,具体效果如下图所示:

# Webpack 中配置 Source Map
我们使用 Webpack 打包的过程,同样支持为打包结果生成对应的 Source Map。用法上也很简单,不过它提供了很多不同模式,导致大部分初学者操作起来可能会比较懵。那接下来我们就一起研究一下在 Webpack 中如何开启 Source Map,然后再来了解一下几种不同的 Source Map 模式之间存在哪些差异。
我们回到配置文件中,这里我们要使用的配置属性叫作 devtool。这个属性就是用来配置开发过程中的辅助工具,也就是与 Source Map 相关的一些功能。我们可以先将这个属性设置为 source-map,具体代码如下:
// ./webpack.config.js
module.exports = {
devtool: 'source-map' // source map 设置
}
然后打开命令行终端,运行 Webpack 打包。打包完成过后,我们打开 dist 目录,此时这个目录中就会生成我们 bundle.js 的 Source Map 文件,与此同时 bundle.js 中也会通过注释引入这个 Source Map 文件,具体如下图所示:

我们再回到命令行,通过 serve 工具把打包结果运行起来,然后打开浏览器,再打开开发人员工具,此时我们就可以直接定位到错误所在的位置了。当然如果需要调试,这里也可以直接调试源代码。
如果你只是需要使用 Source Map 的话,操作到这里就已经实现了。但是只会使用这种最普通的 Source Map 模式还远远不够。
为什么这么说呢?
因为现阶段 Webpack 支持的 Source Map 模式有很多种。每种模式下所生成的 Source Map 效果和生成速度都不一样。显然,效果好的一般生成速度会比较慢,而生成速度快的一般就没有什么效果。
那具体哪种 Source Map 模式才是最好呢?这里我们还需要继续去探索。
Webpack 中的 devtool 配置,除了可以使用 source-map 这个值,它还支持很多其他的选项,具体的我们可以参考文档中的不同模式的对比表。
| devtool 取值 | 初次构建 | 重新构建 | 适合生产环境 | 品质 |
|---|---|---|---|---|
| (none) | 最快 | 最快 | 是 | 无 |
| eval | 最快 | 最快 | 否 | 转换后代码 |
| cheap-eval-source-map | 快 | 更快 | 否 | 转换后代码(只有行信息) |
| cheap-module-eval-source-map | 慢 | 更快 | 否 | 源代码(只有行信息) |
| eval-source-map | 最慢 | 慢 | 否 | 完整源代码 |
| cheap-source-map | 快 | 慢 | 是 | 转换后代码(只有行信息) |
| cheap-module-source-map | 慢 | 更慢 | 是 | 源代码(只有行信息) |
| inline-cheap-source-map | 快 | 慢 | 否 | 转换后代码(只有行信息) |
| inline-cheap-module-source-map | 慢 | 更慢 | 否 | 源代码(只有行信息) |
| source-map | 最慢 | 最慢 | 是 | 完整源代码 |
| inline-source-map | 最慢 | 最慢 | 否 | 完整源代码 |
| hidden-source-map | 最慢 | 最慢 | 是 | 完整源代码 |
| nosources-source-map | 最慢 | 最慢 | 是 | 无源码内容,只有行列信息 |
上表分别从初次构建速度、监视模式重新构建速度、是否适合生成环境使用,以及 Source Map 的质量,这四个维度去横向对比了不同的 Source Map 模式之间的差异。
通过表格中四个维度的对比你可能觉得不够清晰,也不太好理解,所以接下来我们会根据表格中的介绍,通过实际操作来体会这些模式之间的差异,从而带你找到适合自己的最佳实践。
# Eval 模式
首先来看 eval 模式。在去具体了解 Webpack eval 模式的 Source Map 之前,我们需要先了解一下 JavaScript 中 eval 的一些特点。
eval 其实指的是 JavaScript 中的一个函数,可以用来运行字符串中的 JavaScript 代码。例如下面这段代码,字符串中的 console.log("foo~") 就会作为一段 JavaScript 代码被执行:
const code = 'console.log("foo~")'
eval(code) // 将 code 中的字符串作为 JS 代码执行
在默认情况下,这段代码运行在一个临时的虚拟机环境中,我们在控制台中就能够看到:

其实我们可以通过 sourceURL 来声明这段代码所属文件路径,接下来我们再来尝试在执行的 JavaScript 字符串中添加一个 sourceURL 的声明,具体操作如下:

具体就是在 eval 函数执行的字符串代码中添加一个注释,注释的格式:# sourceURL=./path/to/file.js,这样的话这段代码就会执行在指定路径下。
在了解了 eval 函数可以通过 sourceURL 指定代码所属文件路径这个特点过后,我们再来尝试使用这个叫作 eval 模式的 Source Map。
我们回到 Webpack 的配置文件中,将 devtool 属性设置为 eval,具体如下:
// ./webpack.config.js
module.exports = {
devtool: 'eval'
}
然后我们回到命令行终端再次运行打包,打包过后,找到生成的 bundle.js 文件,你会发现每个模块中的代码都被包裹到了一个 eval 函数中,而且每段模块代码的最后都会通过 sourceURL 的方式声明这个模块对应的源文件路径,具体如下:

那此时如果我们回到浏览器运行这里的 bundle.js,一旦出现错误,浏览器的控制台就可以定位到具体是哪个模块中的代码,具体效果如下:

但是当你点击控制台中的文件名打开这个文件后,看到的却是打包后的模块代码,而并非我们真正的源代码,具体如下:

综上所述,在 eval 模式下,Webpack 会将每个模块转换后的代码都放到 eval 函数中执行,并且通过 sourceURL 声明对应的文件路径,这样浏览器就能知道某一行代码到底是在源代码的哪个文件中。
因为在 eval 模式下并不会生成 Source Map 文件,所以它的构建速度最快,但是缺点同样明显:它只能定位源代码的文件路径,无法知道具体的行列信息。
# 案例准备工作
为了可以更好地对比不同模式的 Source Map 之间的差异,这里我们使用一个新项目,同时创建出不同模式下的打包结果,通过具体实验来横向对比它们之间的差异。
